項目55 型のテストを書く
他のプログラミング言語と比べ、TypeScriptでは下記の2つの理由で、特に型のテストの必要性が高い
型には膨大な量のロジックを持てる。ロジックがあるところにはバグの可能性があるため、テストを書くべき
JavaScriptのライブラリ、またTypeScriptのコードでもある程度は実行時の実装から独立して型を定義できるため、その同期を保てるようテストを書くべき
型のテストには主に2つの方法がある
型システムを利用する方法
型システム外のツールを利用する方法
型システムを利用する方法
型宣言によって期待される型が得られるかチェックする
下記の型定義を題材に考える
declare function map<U, V>(array: U[], fn: (u: U) => V): V[];
型宣言された変数に結果を代入して、入出力の型をチェックできるようにする
DefinitelyTypedでは、型宣言時のテストのこの手法で描いている
code:ts
問題点もある
利用されないな変数を定義する必要がある
✨回避策として、アサートヘルパを使う
code:ts
function assertType<T>(x: T) {}
代入によってチェックできるのは、等価性ではなく、代入可能性であること
TypeScriptは値が型に代入可能な構造であれば許可する
code:ts
assertType<{name: string}[]>(
map(beatles, name => ({
name,
inYellowSubmarine: name === 'ringo'
}))
); // inYellowSubmarineプロパティが存在していても許容される
また、代入可能性チェックは関数型のテストでも思わぬ影響を受けることがある
JavaScriptの関数は、宣言されているよりも多くのパラメータで呼び出してもよい点が影響している
code:ts
const add = (a: number, b: number) => a + b;
assertType<(a: number, b: number) => number>(add); // OK
const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double); // 引数の数が足りていないのにOK!?
code:ts
const double = (x: number) => 2 * x;
declare let p: Parameters<typeof double>;
// ~ Argument of type 'number' is not // パラメーターに割り当てることはできません。
declare let r: ReturnType<typeof double>;
assertType<number>(r); // OK
APIの一部としてthisを提供している場合は、その型のテストも記述する
コールバック関数を作成し、その中でパラメータやthisの型チェックをする
code:ts
assertType<number[]>(map(
beatles,
function(name, i, array) {
// ~~~ Argument of type '(name: any, i: any, array: any) => any' is
// not assignable to parameter of type '(u: string) => any'
// 型 '(name: any, i: any, array: any) => any' の引数を
// 型 '(u: string) => any' のパラメーターに割り当てることはできません。
assertType<string>(name);
assertType<number>(i);
assertType<string[]>(array);
assertType<string[]>(this);
// ~~~~ 'this' implicitly has type 'any'
// 'this' は暗黙的に型 'any' になります。
return name.length;
}
));
上記の型エラー回避策として、thisの型を指定した型を定義する
code:ts
declare function map<U, V>(
array: U[],
fn: (this: U[], u: U, i: number, array: U[]) => V
): V[];
ライブラリに型定義が存在しない場合の型テスト方法
型エラーがない場合に、エラーとするネガティブテストを実施する
code:ts
map(
x => x * x,
// @ts-expect-error パラメーターは2つしか取らない
'third parameter'
);
テスト用ライブラリを使う方法
vitestはexpect-typeをバンドルしているテストフレームワーク code:ts
import {expectTypeOf} from 'expect-type';
expectTypeOf(map(
beatles,
function(name, i, array) {
expectTypeOf(name).toEqualTypeOf<string>();
expectTypeOf(i).toEqualTypeOf<number>();
expectTypeOf(array).toEqualTypeOf<string[]>();
expectTypeOf(this).toEqualTypeOf<string[]>();
return name.length;
}
)).toEqualTypeOf<number[]>();
この方法でテストする際の利点
すべての型テストは、すでに使っているtscを使うため、追加のツールが不要
TypeScriptの言語サービスがリファクタリングの手助けになる
この方法でテストする際の欠点
型の不一致を示すエラーメッセージが情報不足
型の構造をテストしているため、型がどう表示されるかに関する問題は検出できない
TypeScript自体に等価性を比較させる珍しい方法
ただ、あまりおすすめしない手法
code:ts
export type Equals<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
export type Expect<T extends true> = T;
const double = (x: number) => 2 * x;
type Test1 = Expect<Equals<typeof double, (x: number) => number>>;
type Test2 = Expect<Equals<typeof double, (x: string) => number>>;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type 'false' does not satisfy the constraint 'true'.
// 型 'false' は制約 'true' を満たしていません。
外部ツールを使う方法
dtslintは、DefinitelyTypedリポジトリで型宣言をテストするために作られたツール
専用フォーマットのコメントを使ってテストを書く
code:ts
map(beatles, function(
name, // $ExpectType string
i, // $ExpectType number
array // $ExpectType string[]
) {
this // $ExpectType string[]
return name.length;
}); // $ExpectType number[]
dtslintは代入可能性をチェックするのではなく、それぞれのシンボルの型を調べ、コメントで指定された型とテキストに基づいて比較する
欠点として、number|string型に対してコメントでstring|numberと記載すると別物として比較される
ただこの欠点を活かしてTypeScriptとしてはstringとanyは代入可能性だが、dtslintでは厳密に比較できる
DefinitelyTypedの型宣言ではなく、自分のTypeScriptの型をテストしたい場合は便利
//スタイルのコメントでも型チェックできる
code:ts
// ^? const spiceGirls: string[]
hr.icon
外部ツールを使ってテストするメリット
エディターで型を調べる方法と同じ要領
型の文字列表現をテストするので、型がどう表示されるかにまつわる問題を補足できる
外部ツールを使ってテストするデメリット
別のツールのセットアップが必要
チェックが厳しすぎる場合がある
number|stringとstring|numberを区別するなど
型がコメント内にあるため、型のフォーマットを変えたりリファクタリングする際に、変更を反映し辛い